This page last changed on May 15, 2009 by aunger.
There are a few gotcha's associated with using Polymorphic associations when you're namescoping your ActiveRecord models.
How it normally works
Here's a simple set of model definitions:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Activity < ActiveRecord::Base
has_many :reports, :as => :reportable
end
class Model < ActiveRecord::Base
has_many :reports, :as => :reportable
end
class Report < ActiveRecord::Base
belongs_to :reportable, :polymorphic => true
end
This is the normal way of doing a polymorphic association. We can do things like:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
report = Report.find(:first)
activity = Activity.find(:first)
activity.reports << report
report.reportable # returns activity
It works because there are two columns in the reports table: reportable_id and reportable_type. reportable_id holds the id of either an Activity or a Model. Reportable_type hold the String representation of the Class of the Model or Activity associated with that report (ie "Model" or "Activity").
When activity.reports is called, ActiveRecord basically makes this SQL request (note the use of base_class instead of class):
SELECT * FROM reports WHERE reportable_id = #{activity.id} AND reportable_type = '#{activity.base_class.name}'
When report.reportable is called, the code basically does this:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
reportable_type.constantize.find(reportable_id) # returns a model or activity
constantize is a way of getting a Class object from the string representation of that Class object's name.
Namescoped models
Now let's imagine a different model for naming the classes – putting the model classes inside a module in order to separate them logically from other models
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Base < ActiveRecord::Base
end
class Name::Activity < Name::Base
has_many :reports, :as => :reportable
end
class Name::Model < Name::Base
has_many :reports, :as => :reportable
end
class Name::Report < Name::Base
belongs_to :reportable, :polymorphic => true
end
There are a couple immediate problems with this approach:
- Activity.reports and Model.reports will be looking for the Report class instead of the Name::Report class. This can be fixed by adding the :class_name parameter to the has_many definition:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Activity < Name::Base
has_many :reports, :as => :reportable, :class_name => "Name::Report"
end
class Name::Model < Name::Base
has_many :reports, :as => :reportable, :class_name => "Name::Report"
end
- Activity.reports ends up looking for the Base Class instead of the object class. This means that it will be looking for reports with a reportable_type of Name::Base instead of Name::Model or Name::Activity. This can be fixed with a monkey-patch (see below).
So now we have (models and monkey-patch):
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Base < ActiveRecord::Base
end
class Name::Activity < Name::Base
has_many :reports, :as => :reportable, :class_name => "Name::Report"
end
class Name::Model < Name::Base
has_many :reports, :as => :reportable, :class_name => "Name::Report"
end
class Name::Report < Name::Base
belongs_to :reportable, :polymorphic => true
end
#monkey patch below
module ActiveRecord
module Associations
class HasManyAssociation
def construct_sql
case
when @reflection.options[:finder_sql]
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
when @reflection.options[:as]
@finder_sql =
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " +
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.name.to_s)}"
@finder_sql << " AND (#{conditions})" if conditions
else
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}"
@finder_sql << " AND (#{conditions})" if conditions
end
if @reflection.options[:counter_sql]
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
elsif @reflection.options[:finder_sql]
# replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
@reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
else
@counter_sql = @finder_sql
end
end
end
end
end
The important line in the monkey-patch is:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.name.to_s)}"
which in the original code looks like:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}"
A further twist
Here at CC, we've been using namescoped classes in one application to access non-namescoped classes in another application in a read-only manner (as it's way faster than ActiveResource). This throws a couple more wrenches in the works.
You now have to be able to convert from Model and Activity to Name::Model and Name::Activity. Unfortunately, ActiveRecord won't automatically try to find classes within the current namescope, so we have to add some more monkey-patching to do it for us.
In order to make the code flexible, I added a new attribute to the has_many association – :unscoped => boolean – and one to the belongs_to association – :namescope => "Name::Scope::String". The :namescope string gets prepended to the class name which gets pulled out of the reportable_type database column, and if :unscoped is specified, all of the module info gets stripped from the class name when searching for reports that belong to a Model or Activity.
The final code and monkey-patch looks like this:
Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Base < ActiveRecord::Base
end
class Name::Activity < Name::Base
has_many :reports, :as => :reportable, :class_name => "Name::Report", :unscoped => true
end
class Name::Model < Name::Base
has_many :reports, :as => :reportable, :class_name => "Name::Report", :unscoped => true
end
class Name::Report < Name::Base
belongs_to :reportable, :polymorphic => true, :namescope => "Name"
end
# monkey patch
module ActiveRecord
module Associations
class BelongsToPolymorphicAssociation
def association_class
scope = @reflection.options[:namescope] ? (@reflection.options[:namescope] + "::") : ""
@owner[@reflection.options[:foreign_type]] ? (scope + @owner[@reflection.options[:foreign_type]]).constantize : nil
end
end
class HasManyAssociation
def construct_sql
case
when @reflection.options[:finder_sql]
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
when @reflection.options[:as]
class_name = @reflection.options[:unscoped] ? @owner.class.name.to_s.split("::").last : @owner.class.base_class.name.to_s
@finder_sql =
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " +
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(class_name)}"
@finder_sql << " AND (#{conditions})" if conditions
else
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}"
@finder_sql << " AND (#{conditions})" if conditions
end
if @reflection.options[:counter_sql]
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
elsif @reflection.options[:finder_sql]
# replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
@reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
else
@counter_sql = @finder_sql
end
end
end
module ClassMethods
@@valid_keys_for_belongs_to_association = [
:class_name, :foreign_key, :foreign_type, :remote, :select, :conditions,
:include, :dependent, :counter_cache, :extend, :polymorphic, :readonly,
:validate, :namescope
]
@@valid_keys_for_has_many_association = [
:class_name, :table_name, :foreign_key, :primary_key,
:dependent,
:select, :conditions, :include, :order, :group, :having, :limit, :offset,
:as, :through, :source, :source_type,
:uniq,
:finder_sql, :counter_sql,
:before_add, :after_add, :before_remove, :after_remove,
:extend, :readonly,
:validate,
:unscoped
]
end
end
end
|